Continuous Integration сервер на Эрланг за 30 минут
В рамкам серии образовательных статей о том как написать свой PaaS в 500
строчек, задля популяризации Эрланга, продвижение оупен соурс, а также движения
"Пиши Мало думай Хорошо" сегодня мы покажем как написать свой CI сервер на
Эрланг с помощью Cowboy и N2O.
Мы будем использовать такие приложения:
{deps, [
{cowboy, ".*", {git, "git://github.com/extend/cowboy.git", "master"}},
{sync, ".*", {git, "git://github.com/doxtop/sync", "HEAD"}},
{erlydtl, ".*", {git, "git://github.com/voxoz/erlydtl.git", "HEAD"}},
{n2o, ".*", {git, "git://github.com/5HT/n2o", "HEAD"}},
{lager, ".*", {git, "git://github.com/basho/lager", {tag,"1.2.2"}}}
]}.
N2O нам нужен для того, что бы быстро накидать сайтик и REST интерфейс к нашему
серверу. Вообще-то это не просто CI, это агент LXC контейнера в нашем Erlang
PaaS, который будет уметь конфигурировать релизы, добавлять и удалять
приложения, деплоить это все в Xen и т.д. Но это все потом, а сейчас напишем
хуки для Ковбоя:
Dispatch = cowboy_router:compile(
[{'_', [
{"/static/[...]", cowboy_static, [{directory, {priv_dir, releaseman, [<<"static">>]}},
{mimetypes, {fun mimetypes:path_to_mimes/2, default}}]},
{"/rest/:bucket", n2o_rest, []}, %% for releases REST interface
{"/rest/:bucket/:key", n2o_rest, []},
{"/rest/:bucket/:key/[...]", n2o_rest, []},
{"/github/hook/[...]", github_handler, []},
{'_', n2o_cowboy, []}
]}
]),
На гитхабе прописывем Веб Хук http://synrc.com/github/hook/USER/REPO для
определенного юзер репо, например для репозитория 5HT/n2o:
http://synrc.com/github/hook/5HT/n2o.
напишем этот обработчик:
-module(github_handler).
-compile(export_all).
-behaviour(cowboy_http_handler).
-export([init/3, handle/2, terminate/3]).
handle(Req, State) ->
{Params,NewReq} = cowboy_req:path(Req),
Path = lists:reverse(string:tokens(binary_to_list(Params),"/")),
[Repo,User|Rest] = Path,
Allowed = lists:member(User,["5HT","doxtop","voxoz","synrc","spawnproc"]),
ResponseReq = case Allowed of
true ->
case global:whereis_name("builder") of
undefined -> spawn(fun() -> global:register_name("builder",self()), builder() end);
Pid -> global:send("builder",{build,Repo,User}) end,
HTML = wf:to_binary(["
202 Project Started to Build
",
"",User,"-",Repo,""]),
{ok, Req3} = cowboy_req:reply(202, [], HTML, NewReq),
Req3;
false ->
NA = wf:to_binary(["404 User not allowed
"]),
{ok, Req4} = cowboy_req:reply(404, [], NA, NewReq),
Req4 end,
{ok, ResponseReq, State}.
здесь важно перечислить тех, чей код вы разрешаете билдить, иначе на вашем
сервере будут билдить все кому не лень, и даже хайджекнуть. Сам билдер очень
простой:
builder() ->
receive
{build,Repo,User} -> build(Repo,User)
end, builder().
build(Repo,User) ->
Docroot = "repos/" ++ Repo,
Buildlogs = "buildlogs/"++ User ++ "-" ++ Repo,
error_logger:info_msg("Hook worker called ~p",[Docroot]),
{{Y,M,D},{H,Min,S}} = calendar:now_to_datetime(now()),
LogFolder = io_lib:format("~p-~p-~p ~p:~p:~p",[Y,M,D,H,Min,S]),
os:cmd(["mkdir -p \"",Docroot,"\""]),
os:cmd(["mkdir -p \"",Buildlogs,"/",LogFolder,"\""]),
Ctx = {User,Repo,Docroot,Buildlogs,LogFolder},
case os:cmd(["ls ",Docroot]) of
[] -> os:cmd(["git clone git@github.com:",User,"/",Repo,".git",Docroot]);
_ -> ok end,
Script = ["git pull","rebar get-deps","rebar compile","./stop.sh","./release.sh",
"./styles.sh","./javascript.sh","./start.sh"],
[ cmd(Ctx,No,lists:nth(No,Script)) || No <- lists:seq(1,length(Script)) ].
Здесь в переменной Script -- то, что вы хотите выполнять. Ну сама команда cmd,
которая пишет логи и т.д.
cmd({User,Repo,Docroot,Buildlogs,LogFolder},No,List) ->
Message = os:cmd(["cd ",Docroot," && ",List]),
FileName =
binary_to_list(base64:encode(lists:flatten([integer_to_list(No)," ",List]))),
File = lists:flatten([Buildlogs,"/",LogFolder,"/",FileName]),
error_logger:info_msg("Command: ~p",[List]),
error_logger:info_msg("Output: ~p",[Message]),
file:write_file(File,Message).
Все, этого достаточно.
Теперь можно написать REST интерфейс к этому серверу, на N2O это выглядит так:
-module(releases_rest).
-compile(export_all).
-include("releases.hrl").
% PUBLIC REST INTERFACE FOR GLOBAL SERVER
-define(RELS, [#release{id="5HT-skyline",name="5HT-skyline",user="5HT",repo="skyline"}] ).
init() -> ets:new(releases, [named_table,{keypos,#release.id},public]),
ets:insert(releases, ?RELS).
get([]) -> ets:foldl(fun(C,Acc) -> [C|Acc] end,[],releases);
get(Id) -> ets:lookup(releases,Id).
delete(Id) -> ets:delete(releases,Id).
put(R=#release{}) -> ets:insert(releases,R).
exists(Id) -> ets:member(releases,Id).
to_html(R=#release{}) -> [<<"">>,coalesce(R#release.id),<<">,
<<" | ">>,coalesce(R#release.user),<<" | ">>,
<<" | ">>,coalesce(R#release.repo),<<" | ">>,
coalesce(R#release.name),<<" |
">>].
coalesce(Name) -> case Name of undefined -> <<>>;
A -> list_to_binary(A) end.
Ну и теперь напишем вебсайтик, который будет состоять из одной странцы:
Вывод всех релизов:
releases() ->
Builds = string:tokens(os:cmd(["ls -1 buildlogs"]),"\n"),
[ #h1{ body = "Continuos Integration"}, #h2{ body = "Builds" },
[ #p{ body = #link { body = R, url= "/index?release="++R }} || R <- Builds ] ,
#br{},#br{},#br{},
#span{ body = "© Synrc Research Center" }
].
Вывод билдов для каждого релиза:
builds(Release) ->
error_logger:info_msg("builds: ~p",[Release]),
Builds = string:tokens(os:cmd(["ls -1 buildlogs/",Release]),"\n"),
[ #h2{ body = "Builds for " ++ Release },
[ #p{ body = #link { body = B, url= "/index?release="++Release++"&build="++B }} || B <- Builds ]
].
Вывод шагов для каждого релиза:
steps(Release,Build) ->
error_logger:info_msg("steps: ~p ~p",[Release,Build]),
Steps = string:tokens(os:cmd(["ls -1 \"buildlogs/",Release,"/",Build,"\""]),"\n"),
[ #h2{ body = "Steps for " ++ Build ++ " build of " ++ Release ++ " release" },
[ #p{ body = #link { body = wf:to_list(base64:decode(S)),
url= "/index?release="++Release++"&build="++Build++"&log="++S }} || S <- lists:sort(Steps) ] ].
Вывод лога:
log(Release,Build,Step) ->
error_logger:info_msg("log: ~p ~p ~p",[Release,Build,Step]),
{ok,Bin} = file:read_file(["buildlogs/",Release,"/",Build,"/",Step]),
[<<"">>,Bin,<<"">>].
Ну и сам модуль страницы:
-module(public_index).
-compile(export_all).
-include_lib("n2o/include/wf.hrl").
-include_lib("kernel/include/file.hrl").
-include_lib("releaseman/include/releases.hrl").
main() -> [ #dtl{file = "index",
app=releaseman,bindings=[{title,title()},{body,body()}]} ].
title() -> [ <<"RELEASE MANAGER">> ].
body() ->
case {wf:qs(<<"release">>),wf:qs(<<"build">>),wf:qs(<<"log">>)} of
{undefined,undefined,undefined} -> releases();
{Release,undefined,undefined} -> builds(binary_to_list(Release));
{Release,Build,undefined} -> steps(binary_to_list(Release),binary_to_list(Build));
{Release,Build,Step} -> log(binary_to_list(Release),
binary_to_list(Build),binary_to_list(Step)) end.
Вот как это выглядит, наш Билд сервер: http://build.synrc.com/